홈으로 돌아가기
#OS

OS (26) 분산 시스템(Distributed Systems): 실패를 다루는 기술

2026-02-08

분산 시스템(Distributed Systems): 실패를 다루는 기술

안녕하세요! 오늘은 운영체제 분야에서 가장 흥미로우면서도 복잡한 주제인 분산 시스템(Distributed Systems) 에 대해 깊이 있게 다뤄보려고 합니다.

우리가 매일 사용하는 웹 브라우저, 구글, 페이스북 같은 서비스들은 단일 컴퓨터에서 돌아가는 것이 아닙니다. 지구 어딘가에 있는 수천 대의 기계들이 협력하여 서비스를 제공합니다. 이것이 바로 분산 시스템의 본질입니다. 하지만 분산 시스템 개발은 결코 쉽지 않습니다. 어떻게 하면 종종 고장 나는 부품들을 가지고 영원히 멈추지 않는 시스템을 만들 수 있을까? 이것이 우리가 오늘 해결해야 할 핵심 질문입니다.

이 글은 OSTEP(Operating Systems: Three Easy Pieces)의 50장 내용을 바탕으로 작성되었으며, 분산 시스템의 기본 개념부터 통신, RPC, 그리고 시스템 설계 철학까지 상세하게 정리했습니다.


1. 분산 시스템의 핵심: 실패(Failure)

분산 시스템을 공부할 때 가장 먼저 받아들여야 할 사실은 시스템은 반드시 실패한다 는 것입니다. 우리는 완벽한 시스템을 만드는 법을 모릅니다. 기계는 고장 나고, 디스크는 깨지고, 네트워크 케이블은 뽑히며, 소프트웨어는 버그를 일으킵니다.

하지만 사용자 입장에서는 웹 서비스가 절대 중지하지 않는 것처럼 보여야 합니다. 즉, 분산 시스템의 핵심 사안은 실패와 고장의 극복 입니다. 개별 구성 요소는 자주 고장 나지만, 전체 시스템은 마치 고장이 없는 것처럼 보이게 만드는 것, 이것이 분산 시스템의 아름다움이자 가치입니다.

물론 성능(Performance)도 중요합니다. 네트워크를 통해 메시지를 주고받는 것은 느리기 때문에, 우리는 전송 메시지 수를 줄이고 지연 시간(latency)을 낮추며 대역폭(bandwidth)을 높이는 효율적인 설계를 고민해야 합니다.


2. 통신의 기본: 신뢰할 수 없는 세상

분산 시스템에서 통신은 근본적으로 신뢰할 수 없다(Unreliable) 고 가정해야 합니다.

왜 패킷은 사라지는가?

패킷 손실이나 손상에는 여러 이유가 있습니다.

  1. 비트 오류: 전송 중에 전기적인 문제로 비트가 반전될 수 있습니다.
  2. 물리적 단절: 케이블이 끊어지거나 기계가 멈출 수 있습니다.
  3. 버퍼 오버플로우 (가장 흔한 원인): 라우터나 스위치, 혹은 종단 호스트(End host)에는 패킷을 처리하기 전 잠시 담아두는 메모리(버퍼)가 있습니다. 만약 처리 속도보다 패킷이 들어오는 속도가 빠르면 버퍼가 가득 차게 되고, 그 이후 들어오는 패킷은 그냥 버려집니다(Drop).

이처럼 패킷 손실은 네트워킹의 근본적인 문제입니다. 그렇다면 우리는 이에 어떻게 대처해야 할까요?


3. 신뢰할 수 없는 통신 계층 (Unreliable Layer)

가장 간단한 방법은 아무런 조치도 취하지 않는 것입니다. 이를 보여주는 대표적인 예가 UDP(User Datagram Protocol) 입니다.

UDP는 패킷을 보냅니다. 하지만 그 패킷이 도착했는지, 중간에 사라졌는지 확인하지 않습니다. 발신자는 손실에 대해 전혀 알 수 없습니다. (물론 체크섬을 통해 데이터 손상은 검출할 수 있지만, 손실 자체를 막아주진 않습니다.)

아래는 UDP를 이용한 간단한 클라이언트/서버 코드 예제입니다. 이 코드는 메시지를 보내고 받지만, 네트워크 상황에 따라 언제든 실패할 수 있습니다.

UDP 클라이언트 코드 예시 (개념적)

// 클라이언트
int main(int argc, char *argv[]) {
    struct sockaddr_in addr, addr2;
    char message[BUFFER_SIZE];
    sprintf(message, "hello world");
    
    // 주소 설정 및 소켓 생성
    int rc = UDP_FillSockAddr(&addr, "machine.cs.wisc.edu", 10000);
    int sd = UDP_Open(20000); // 소켓 열기
    
    // 메시지 전송 (도착 보장 없음)
    rc = UDP_Write(sd, &addr, message, BUFFER_SIZE);
    
    if (rc > 0) {
        // 서버의 응답 대기
        int rc = UDP_Read(sd, &addr2, buffer, BUFFER_SIZE);
    }
    return 0;
}

많은 응용 프로그램은 이런 불안정함을 원하지 않습니다. 그래서 우리는 이 신뢰할 수 없는 계층 위에 신뢰할 수 있는 통신 계층 을 쌓아 올려야 합니다.


4. 신뢰할 수 있는 통신 계층 만들기 (Reliable Layer)

신뢰할 수 없는 네트워크 위에서 신뢰성을 확보하기 위해 우리는 몇 가지 핵심 기술(Mechanism)을 도입해야 합니다. TCP와 같은 프로토콜이 내부적으로 사용하는 기술들이기도 합니다.

기술 1: 확인(Acknowledgement, ACK)

발신자가 메시지를 보냈을 때, 수신자가 이를 잘 받았는지 어떻게 알 수 있을까요? 수신자는 메시지를 받으면 잘 받았음"이라는 짧은 메시지(ACK) 를 발신자에게 되돌려 보냅니다.

기술 2: 타임아웃(Timeout)과 재시도(Retry)

만약 메시지가 가다가 사라지거나, 메시지는 잘 갔는데 돌아오는 ACK가 사라지면 어떻게 될까요? 발신자는 하염없이 기다리게 됩니다. 이를 해결하기 위해 타임아웃 을 설정합니다.

  1. 발신자는 메시지를 보내면서 타이머 를 켭니다.
  2. 동시에 혹시 모를 재전송을 위해 메시지의 사본 을 보관합니다.
  3. 일정 시간 내에 ACK가 오지 않으면, 메시지가 손실되었다고 판단하고 재전송 합니다.

이때 타임아웃 값의 설정 이 매우 중요합니다.

기술 3: 순서 카운터(Sequence Counter)와 중복 방지

타임아웃/재시도 방식에는 치명적인 문제가 하나 있습니다. 메시지는 잘 도착했는데, ACK만 사라진 경우 를 생각해 봅시다.

  1. 발신자: 메시지 전송 -> (성공) -> 수신자: 메시지 처리 및 ACK 전송 -> (ACK 손실)
  2. 발신자: 타임아웃 발생! -> 메시지 재전송
  3. 수신자: 똑같은 메시지를 또 받음!

파일을 다운로드하거나 결제 요청을 처리할 때 중복 실행은 치명적입니다. 따라서 수신자는 이것이 새로운 메시지인지, 아까 받은 메시지의 재전송인지 구분해야 합니다.

이를 위해 순서 카운터(Sequence Counter) 를 사용합니다.

이 세 가지 기술(ACK, 타임아웃/재시도, 순서 번호)을 조합하면, 신뢰할 수 없는 네트워크 위에서도 신뢰성 있는 통신(TCP 등)을 구현할 수 있습니다.


5. 통신 추상화 (Communication Abstraction)

기본적인 통신 계층이 마련되었다면, 이제 개발자가 분산 시스템을 더 쉽게 만들 수 있도록 추상화된 개념이 필요합니다. 역사적으로 두 가지 주요 접근 방식이 있었습니다.

접근 1: 분산 공유 메모리 (Distributed Shared Memory, DSM)

운영체제 연구자들은 "분산 시스템을 마치 멀티스레드 프로그래밍처럼 만들면 어떨까?"라고 생각했습니다.

접근 2: 원격 프로시저 호출 (Remote Procedure Call, RPC)

이 방식은 프로그래밍 언어(PL) 관점에서 접근했습니다.


6. RPC(Remote Procedure Call)의 내부

RPC는 클라이언트가 서버의 함수를 호출하고 결과를 받는 과정을 단순화합니다. 이를 위해 스텁 생성기(Stub Generator)런타임 라이브러리(Runtime Library) 가 필요합니다.

6.1 스텁 생성기 (Stub Generator)

스텁 생성기는 인터페이스 정의를 읽어서 클라이언트와 서버 양쪽에 필요한 코드를 자동으로 만들어줍니다. 이를 통해 개발자는 복잡한 네트워크 코드를 짤 필요가 없습니다.

클라이언트 스텁(Client Stub)이 하는 일:

  1. 메시지 버퍼 생성: 데이터를 담을 패킷을 준비합니다.
  2. 마샬링(Marshaling) / 직렬화(Serialization): 함수 이름과 인자(argument)들을 패킷에 차곡차곡 담습니다.
  3. 전송: RPC 런타임을 통해 서버로 메시지를 보냅니다.
  4. 언마샬링(Unmarshaling): 서버로부터 응답이 오면 결과를 해석해서 클라이언트 코드에 반환합니다.

서버 스텁(Server Stub)이 하는 일:

  1. 언마샬링: 도착한 메시지를 풀어서 어떤 함수를 호출할지, 인자는 무엇인지 알아냅니다.
  2. 함수 호출: 실제 서버 쪽의 함수를 실행합니다.
  3. 결과 마샬링: 실행 결과를 다시 패킷으로 포장하여 클라이언트에게 보냅니다.

6.2 RPC의 기술적 난관들

RPC는 겉보기엔 단순하지만 내부적으로 해결해야 할 복잡한 문제들이 있습니다.

문제 1: 포인터와 복잡한 자료구조 로컬 함수 호출에서는 포인터(메모리 주소)를 전달하는 것이 흔합니다. 하지만 RPC에서는?

문제 2: 바이트 순서 (Byte Ordering/Endianness) 어떤 기계는 빅 엔디안(Big Endian) 을 쓰고, 어떤 기계는 리틀 엔디안(Little Endian) 을 씁니다.

문제 3: 성능과 전송 계층 (Transport Layer) RPC는 TCP 위에서 구현해야 할까요, UDP 위에서 구현해야 할까요?

문제 4: 병행성 (Concurrency) 서버가 한 번에 하나의 요청만 처리한다면(Iterative Server), I/O 작업 중에 다른 요청들은 하염없이 기다려야 합니다.


7. 단-대-단 논쟁 (The End-to-End Argument)

분산 시스템 설계에서 가장 중요한 철학 중 하나인 End-to-End Argument(단-대-단 논쟁) 에 대해 이야기해 봅시다. Saltzer 등이 제안한 이 개념은 시스템의 기능을 어디에 구현해야 하는지에 대한 가이드라인입니다.

예시: 파일 전송

A 컴퓨터에서 B 컴퓨터로 파일을 보낸다고 가정합시다. 데이터가 깨지지 않고 완벽하게 전송되기를 원합니다. "네트워크 계층(하위 계층)에서 100% 신뢰성을 보장해주면 되는 거 아냐?"라고 생각할 수 있습니다.

하지만 하위 계층이 완벽해도 문제는 생깁니다.

  1. 발신자의 메모리에서 데이터가 이미 깨져 있었다면?
  2. 수신자가 데이터를 받아서 디스크에 쓸 때 오류가 났다면?

네트워크가 아무리 완벽하게 데이터를 날라줘도, 파일 전송이라는 최상위 응용 프로그램의 목표(End-to-End Goal) 는 달성되지 않을 수 있습니다. 결국 신뢰성 있는 파일 전송을 보장하려면, 애플리케이션 계층(가장 끝단) 에서 전송 완료 후 파일의 체크섬(Checksum)을 비교하는 등의 검증을 반드시 수행해야 합니다.

교훈


8. 요약 및 결론

분산 시스템은 전 세계를 연결하는 현대 컴퓨팅의 기반입니다. 하지만 "실패(Failure)"라는 피할 수 없는 현실 위에서 동작합니다.

  1. 통신은 불안정합니다: 패킷은 언제든 손실될 수 있습니다.
  2. 신뢰성 확보: 이를 극복하기 위해 ACK, 타임아웃, 재시도, 순서 번호 같은 기술을 사용합니다.
  3. RPC: 분산 시스템을 쉽게 개발하기 위해 원격 호출을 로컬 호출처럼 보이게 하는 RPC 추상화를 주로 사용합니다. 스텁 생성기와 런타임이 복잡한 마샬링, 네이밍, 프로토콜 처리를 대신해줍니다.
  4. End-to-End Argument: 하위 계층의 신뢰성만 믿지 말고, 애플리케이션 레벨에서 최종적인 무결성을 검증해야 합니다.

이 챕터를 통해 여러분은 구글이나 페이스북 같은 거대한 시스템이 어떤 원리로, 그리고 어떤 고민들 속에서 동작하는지 이해하는 첫걸음을 떼셨습니다. 분산 시스템은 어렵지만, 불완전한 부품들로 완벽에 가까운 서비스를 만들어내는 매력적인 분야입니다.


참고